Skip to main content

Verify OTP

POST/api/v1/users/auth/verify-otp

Exchanges a valid 6-digit OTP for a 15-minute access JWT and a refresh token (sliding 30 days, absolute 90 days). Sets Cache-Control: no-store on the response.

cv-api-key
Productionhttps://api.care360-next.carevalidate.com/api/v1/users/auth/verify-otp
Staginghttps://api-staging.care360-next.carevalidate.com/api/v1/users/auth/verify-otp
note

Provide exactly one of email or phoneNumber (the same identifier used with /send-otp) plus the 6-digit code.

Headers

Headers
cv-api-keystringrequired

Your unique API key for authentication.

Content-Typestringrequired

Must be application/json.

Request Body

Body
emailstringoptional

Patient's email address. Required if phoneNumber is not provided.

Example: patient@example.com
phoneNumberstringoptional

E.164-formatted phone number. Required if email is not provided.

Example: +15551234567
codestringrequired

The 6-digit numeric OTP delivered by /send-otp. Must match the regex ^\d{6}$.

Example: 123456
note

Cross-field rules: exactly one of email or phoneNumber must be provided.

Behavior

  1. Resolves the organization from cv-api-key.
  2. Looks up the user (scoped to the organization). If no user is found, the server still performs a uniform-timing claim against a dummy user id and rejects with Invalid credentials.
  3. Atomically claims an attempt against UserAuthOtp using a single conditional UPDATE ... RETURNING (no read-then-increment race). Succeeds only if the row is unused, not expired, and below maxAttempts.
  4. SHA3-512-hashes the supplied code and compares to the stored hash. Mismatch → Invalid verification code (the attempt was already counted).
  5. On match, performs a CAS update to mark the OTP usedAt. If a concurrent request already won, returns Verification code already used.
  6. Deletes all OTP rows for the user (best effort).
  7. Looks up the user's OrganizationAccess role and issues a new refresh-token family.

Token Issuance

Successful verification issues:

  • Access token (JWT, HS512) — 15-minute expiry, claims { userId, organizationId, type: "patient-portal", role, organizationAccessRole }.
  • Refresh token — 32 random bytes encoded as base64url. Stored only as a SHA3-512 hash. Sliding expiry 30 days, absolute expiry 90 days.

The response sets Cache-Control: no-store.

Example Request

curl -X POST '<BASE_URL>/api/v1/users/auth/verify-otp' \
-H 'cv-api-key: <redacted>' \
-H 'Content-Type: application/json' \
-d '{
"email": "patient@example.com",
"code": "123456"
}'

Responses

200SuccessOTP accepted. Returns access + refresh tokens. Response includes Cache-Control: no-store.
{
"status": 200,
"success": true,
"accessToken": "<JWT, HS512>",
"expiresIn": 900,
"refreshToken": "<opaque-base64url, 32 random bytes>",
"refreshTokenExpiresAt": "2026-05-29T12:00:00.000Z",
"patientId": "550e8400-e29b-41d4-a716-446655440000"
}
400Validation errorcv-api-key missing, body fails Zod, or code is not 6 digits.
{
"status": 400,
"success": false,
"error": "Validation failed",
"code": "VALIDATION_ERROR"
}
404Organization not foundcv-api-key does not resolve to a partner organization.
{
"status": 404,
"success": false,
"error": "Organization not found",
"code": "NOT_FOUND"
}
401Invalid credentialsNo user matches the provided identifier in this organization.
{
"status": 401,
"success": false,
"error": "Invalid credentials",
"code": "VALIDATION_ERROR"
}
401Invalid or expired verification codeNo active OTP for the user, or attempts >= maxAttempts.
{
"status": 401,
"success": false,
"error": "Invalid or expired verification code",
"code": "VALIDATION_ERROR"
}
401Invalid verification codeCode hash did not match. The attempt was counted.
{
"status": 401,
"success": false,
"error": "Invalid verification code",
"code": "VALIDATION_ERROR"
}
401Verification code already usedCAS lost — another concurrent request already consumed the OTP.
{
"status": 401,
"success": false,
"error": "Verification code already used",
"code": "VALIDATION_ERROR"
}
note

All 401 responses use a deliberately small set of generic messages to prevent enumeration. Do not surface specific OTP-failure reasons to end users beyond what is returned.

Try It Out